Fork me on GitHub

2.2.2——Java虚拟机栈

接下来的时间会不定期的更新关于JVM的知识,还是了解的太少啊!!!

那今天我们就来主要讲讲Java虚拟机栈吧!

前言:

Java 虚拟机的内存模型分为两部分:一部分是线程共享的,包括 Java 堆和方法区;另一部分是线程私有的,包括虚拟机栈和本地方法栈,以及程序计数器这一小部分内存。

熟悉 Java 的同学应该都知道了,JVM 是基于栈的。但是这个“栈” 具体指的是什么?难道就是虚拟机栈?想要回答这个问题我们先要从虚拟机栈的结构谈起。

虚拟机栈

那到底何为虚拟机栈呢?

深入理解Java虚拟机中是这样说的,Java虚拟机栈(Java Virtual Machine Stacks)也是线程私有的,它的生命周期与线程相同。虚拟机栈描述的是Java方法执行的内存模型:每个方法在执行的同时都会创建一个栈帧(Stack Frame)用于存储局部变量表,操作数栈,动态链接,方法出口等信息。每一个方法从调用直至执行完成的过程,就对应着一个栈帧在虚拟机栈中入栈到出栈的过程。

以我的理解来解释一下啊

虚拟机栈的栈元素是栈帧,当有一个方法被调用时,代表这个方法的栈帧入栈;当这个方法返回时,其栈帧出栈。因此,虚拟机栈中栈帧的入栈顺序就是方法调用顺序。什么是栈帧呢?栈帧可以理解为一个方法的运行空间。它主要由两部分构成,一部分是局部变量表,方法中定义的局部变量以及方法的参数就存放在这张表中;另一部分是操作数栈,用来存放操作数。

我们知道,Java 程序编译之后就变成了一条条字节码指令,其形式类似汇编,但和汇编有不同之处:汇编指令的操作数存放在数据段和寄存器中,可通过存储器或寄存器寻址找到需要的操作数;而 Java 字节码指令的操作数存放在操作数栈中,当执行某条带 n 个操作数的指令时,就从栈顶取 n 个操作数,然后把指令的计算结果(如果有的话)入栈。因此,当我们说 JVM 执行引擎是基于栈的时候,其中的“栈”指的就是操作数栈。举个简单的例子对比下汇编指令和 Java 字节码指令的执行过程,比如计算 1 + 2,在汇编指令是这样的:

1
2
mov ax, 1 ;//把 1 放入寄存器 ax
add ax, 2 ;//用 ax 的内容和 2 相加后存入 ax

而 JVM 的字节码指令是这样的:

1
2
3
iconst_1 //把整数 1 压入操作数栈
iconst_2 //把整数 2 压入操作数栈
iadd //栈顶的两个数相加后出栈,结果入栈

由于操作数栈是内存空间,所以字节码指令不必担心不同机器上寄存器以及机器指令的差别,从而做到了平台无关。

注意:局部变量表中的变量不可直接使用,如需使用必须通过相关指令将其加载至操作数栈中作为操作数使用。

比如有一个方法 void foo(),其中的代码为:int a = 1 + 2; int b = a + 3;,编译为字节码指令就是这样的:

1
2
3
4
5
6
7
8
9
iconst_1 //把整数 1 压入操作数栈
iconst_2 //把整数 2 压入操作数栈
iadd //栈顶的两个数出栈后相加,结果入栈;实际上前三步会被编译器优化为:iconst_3
istore_1 //把栈顶的内容放入局部变量表中索引为 1 的 slot 中,也就是 a 对应的空间中
iload_1 // 把局部变量表索引为 1 的 slot 中存放的变量值(3)加载至操作数栈
iconst_3
iadd //栈顶的两个数出栈后相加,结果入栈
istore_2 // 把栈顶的内容放入局部变量表中索引为 2 的 slot 中,也就是 b 对应的空间中
return // 方法返回指令,回到调用点

需要说明的是,局部变量表以及操作数栈的容量的最大值在编译时就已经确定了,运行时不会改变。并且局部变量表的空间是可以复用的,例如,当指令的位置超出了局部变量表中某个变量 a 的作用域时,如果有新的局部变量 b 要被定义,b 就会覆盖 a 在局部变量表的空间。

盗用别人的图以让大家对虚拟机栈有个直观的认识(其中小字体 Stack 指的的是虚拟机栈,Frame 是栈帧,Local variables 是局部变量表,Operand Stack 是操作数栈):

image

由虚拟机栈引出的问题

看完上面的代码大家可能会有几点疑惑:什么是 slot?那些指令是什么意思?为什么 a 对应的 slot 的索引值不是从零开始的,它明明是第一个定义的变量啊?

对于这些问题我们一个个来解决。

什么是 slot

首先什么是 slot?slot 是局部变量表中的空间单位,虚拟机规范中有规定,对于 32 位之内的数据,用一个 slot 来存放,如 int,short,float 等;对于 64 位的数据用连续的两个 slot 来存放,如 long,double 等。引用类型的变量 JVM 并没有规定其长度,它可能是 32 位,也有可能是 64 位的,所以既有可能占一个 slot,也有可能占两个 slot。

书中是这样说的,局部变量表存放了编译期可知的各种基本数据类型(boolean、byte、char、short、int、float、long、double)、对象引用(reference类型,它不等同于对象本身,可能是一个指向对象起始地址的引用指针,也可能是指向一个代表对象的句柄或其他与此对象相关的位置)和returnAddress类型(指向了一条字节码指令的地址)。

局部变量表的第一个变量

从 Java 语言的层面讲,静态方法和实例方法的本质区别在于是否是对象所共享的。而从 JVM 的角度来看,方法(无论静态方法还是实例方法)其实都是对象共享的,实例变量才是对象私有的。
对 JVM 而言,静态方法和实例方法的本质区别在于是否需要和具体对象关联:静态方法可以通过类名来调用,它不需要和具体对象关联;而实例方法必须通过对象来进行调用,它需要和具体对象关联。那么,实例方法和具体对象是如何产生关联的呢?

来我们看一下Thinking in Java Fourth Edition这样解释的

如果有同一类型的两个对象,分别是a和b。你可能想知道,如何才能让这两个对象都能调用peel()方法呢?

1
2
3
4
5
class Banana {
void peel(int i){
System.out.println(i)
}
}
1
2
3
4
5
6
7
8
public class BananaPeel {
public static void main(String[] args){
Banana a = new Banana();
b = new Banana();
a.peel(1);
b.peel(2);
}
}

如果只有一个peel方法,它如何知道是被a还是被b所调用的呢?

为了能简便、面向对象的语法来编写代码—即”发送消息给对象”,编译器做了一些幕后工作。它暗自把”所操作对象的引用”作为第一个参数传递给peel()。所以上述两个方法的调用就变成了这样:

1
2
Banana.peel(a,1);
Banana.peel(b,2);

这是内部的表示形式。我们并不能这样书写代码,因为你编译期间就过不了,这样写只是为了让大家更容易理解罢了。

假设你希望在方法内部获得对当前对象的引用。由于这个引用是由编译器”偷偷”传入的,所以没有标识符可用。但是有个专门的关键字:this。this关键字只能在方法内部使用,表示对”调用方法的那个对象”的引用。this关键字的用法和其他对象引用并无不同。

如果看懂了上面说的那些,第三个问题也就迎刃而解了。因为我们定义的方法是 void foo(),它是实例方法,因此会有一个指向具体对象的隐含参数 this,this 就存放在局部变量表的第一个位置,即存放在索引为 0 的 slot 中,又由于它的作用域从方法开始一直到方法结束,因此它在局部变量表中的位置不会被其他变量覆盖,从而使得我们在方法中定义的变量只能放在局部变量表后面的位置中。需要注意的是,如果方法有参数(非隐含参数),那么参数会按顺序紧接着 this 存放在局部变量表中,由于参数作用域也是整个方法体,所以方法中定义的局部变量就只能放在参数后面了。总的来说局部变量表中变量的存放顺序为: this(如果是实例方法)=> 参数(如果有的话)=> 定义的局部变量(如果有的话)。